SpringBoot MVC 分层最佳实践
1. 项目结构规范
1.1 推荐目录结构
src/main/java/com/example/project/
├── controller/ # 控制器层
│ └── UserController.java
├── service/ # 服务层接口
│ └── UserService.java
├── service/impl/ # 服务层实现
│ └── UserServiceImpl.java
├── mapper/ # MyBatis Mapper 接口
│ └── UserMapper.java
├── model/ # 模型层(model/pojo)
│ ├── entity/ # 数据库实体(entity/domain)
│ │ └── User.java
│ ├── dto/ # 数据传输对象(请求)
│ │ └── user/
│ │ ├── UserRegisterDTO.java
│ │ ├── UserLoginDTO.java
│ │ └── UserUpdateDTO.java
│ ├── vo/ # 视图对象(响应)
│ │ └── UserVO.java
│ ├── query/ # 查询对象
│ │ └── UserQueryDTO.java
│ └── enums/ # 枚举类
│ ├── GenderEnum.java
│ ├── UserStatusEnum.java
│ └── UserRoleEnum.java
├── common/ # 公共模块
│ ├── BaseResponse.java
│ ├── ErrorCode.java
│ ├── ResultUtils.java
│ └── PageResult.java
├── exception/ # 异常处理
│ ├── BusinessException.java
│ └── GlobalExceptionHandler.java
├── config/ # 配置类
│ └── MyBatisPlusConfig.java
├── utils/ # 工具类
│ └── PasswordUtils.java
└── validation/ # 自定义校验器
└── groups/
└── UpdateGroup.java
1.2 命名规范演进
| 旧名称 |
新名称(推荐) |
说明 |
| POJO |
Model |
更通用的术语 |
| Domain |
Entity |
明确表示数据库实体 |
| Request |
DTO |
数据传输对象,用于请求 |
| Response |
VO |
视图对象,用于响应 |
2. 实体类设计规范
2.1 数据库设计原则
CREATE TABLE `user` (
`id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
`user_name` VARCHAR(50) NOT NULL COMMENT '用户昵称',
`user_account` VARCHAR(50) NOT NULL COMMENT '账号',
`user_password` VARCHAR(256) NOT NULL COMMENT '密码',
`avatar_url` VARCHAR(512) DEFAULT NULL COMMENT '头像URL',
`gender` VARCHAR(10) DEFAULT 'UNKNOWN' COMMENT '性别',
`phone` VARCHAR(20) DEFAULT NULL COMMENT '手机号',
`email` VARCHAR(100) DEFAULT NULL COMMENT '邮箱',
`user_status` VARCHAR(20) DEFAULT 'ACTIVE' COMMENT '用户状态',
`user_role` VARCHAR(20) DEFAULT 'USER' COMMENT '用户角色',
`tags` VARCHAR(1024) DEFAULT NULL COMMENT '标签JSON',
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`is_delete` TINYINT DEFAULT 0 COMMENT '逻辑删除标识',
UNIQUE KEY `uk_user_account` (`user_account`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';
2.2 Entity 实体类
package com.zwnsyw.zwwwspringbootbasetemplate.model.entity;
import com.baomidou.mybatisplus.annotation.*;
import com.zwnsyw.zwwwspringbootbasetemplate.model.enums.GenderEnum;
import com.zwnsyw.zwwwspringbootbasetemplate.model.enums.UserRoleEnum;
import com.zwnsyw.zwwwspringbootbasetemplate.model.enums.UserStatusEnum;
import lombok.Data;
import java.io.Serializable;
import java.time.LocalDateTime;
@Data
@TableName("user")
public class User implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private String userName;
private String userAccount;
private String userPassword;
private String avatarUrl;
private GenderEnum gender;
private String phone;
private String email;
private UserStatusEnum userStatus;
private UserRoleEnum userRole;
private String tags;
private LocalDateTime createTime;
private LocalDateTime updateTime;
@TableLogic
private Integer isDelete;
}
2.3 Entity 设计原则
| 原则 |
说明 |
| 不放校验注解 |
Entity 是数据库映射,校验逻辑放在 DTO |
| 使用枚举类型 |
固定选项字段使用枚举,提高类型安全 |
| 使用 LocalDateTime |
替代 Date,更现代的时间 API |
| 逻辑删除字段 |
使用 @TableLogic 注解 |
| 序列化支持 |
实现 Serializable 接口 |
3. 枚举设计规范
3.1 用户性别枚举
package com.zwnsyw.zwwwspringbootbasetemplate.model.enums;
import com.baomidou.mybatisplus.annotation.EnumValue;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonValue;
public enum GenderEnum {
MALE("MALE", "男"),
FEMALE("FEMALE", "女"),
UNKNOWN("UNKNOWN", "保密");
@EnumValue
private final String code;
private final String description;
GenderEnum(String code, String description) {
this.code = code;
this.description = description;
}
@JsonValue
public String getCode() {
return code;
}
public String getDescription() {
return description;
}
@JsonCreator
public static GenderEnum fromCode(String code) {
if (code == null) {
return UNKNOWN;
}
for (GenderEnum gender : values()) {
if (gender.code.equalsIgnoreCase(code)) {
return gender;
}
}
return UNKNOWN;
}
public static boolean isValid(String code) {
for (GenderEnum gender : values()) {
if (gender.code.equalsIgnoreCase(code)) {
return true;
}
}
return false;
}
}
3.2 用户状态枚举
package com.zwnsyw.zwwwspringbootbasetemplate.model.enums;
import com.baomidou.mybatisplus.annotation.EnumValue;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonValue;
public enum UserStatusEnum {
ACTIVE("ACTIVE", "正常"),
DISABLED("DISABLED", "禁用"),
LOCKED("LOCKED", "锁定");
@EnumValue
private final String code;
private final String description;
UserStatusEnum(String code, String description) {
this.code = code;
this.description = description;
}
@JsonValue
public String getCode() {
return code;
}
public String getDescription() {
return description;
}
@JsonCreator
public static UserStatusEnum fromCode(String code) {
if (code == null) {
return ACTIVE;
}
for (UserStatusEnum status : values()) {
if (status.code.equalsIgnoreCase(code)) {
return status;
}
}
throw new IllegalArgumentException("未知的用户状态: " + code);
}
}
3.3 用户角色枚举
package com.zwnsyw.zwwwspringbootbasetemplate.model.enums;
import com.baomidou.mybatisplus.annotation.EnumValue;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonValue;
import lombok.Getter;
@Getter
public enum UserRoleEnum {
USER("USER", "普通用户"),
ADMIN("ADMIN", "管理员"),
BAN("BAN", "封禁用户");
@EnumValue
private final String code;
private final String description;
UserRoleEnum(String code, String description) {
this.code = code;
this.description = description;
}
@JsonValue
public String getCode() {
return code;
}
@JsonCreator
public static UserRoleEnum fromCode(String code) {
if (code == null) {
return null;
}
for (UserRoleEnum role : values()) {
if (role.code.equalsIgnoreCase(code)) {
return role;
}
}
throw new IllegalArgumentException("未知的用户角色: " + code);
}
public boolean isAdmin() {
return this == ADMIN;
}
public boolean isBanned() {
return this == BAN;
}
public boolean isUser() {
return this == USER;
}
}
3.4 枚举设计总结
| 注解 |
作用 |
使用位置 |
| @EnumValue |
指定存储到数据库的字段 |
code 字段 |
| @JsonValue |
指定序列化到 JSON 的字段 |
description 字段 |
| @JsonCreator |
指定反序列化时的工厂方法 |
fromCode 方法 |
4. DTO/VO 设计规范
4.1 DTO(Data Transfer Object)- 请求数据
用户注册 DTO
package com.example.project.model.dto.user;
import lombok.Data;
import javax.validation.constraints.*;
import java.io.Serializable;
@Data
public class UserRegisterDTO implements Serializable {
private static final long serialVersionUID = 1L;
@NotBlank(message = "账号不能为空")
@Size(min = 4, max = 20, message = "账号长度必须在4-20之间")
@Pattern(regexp = "^[a-zA-Z0-9_]+$", message = "账号只能包含字母、数字和下划线")
private String userAccount;
@NotBlank(message = "密码不能为空")
@Size(min = 8, max = 20, message = "密码长度必须在8-20之间")
@Pattern(
regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)[a-zA-Z\\d@$!%*?&]+$",
message = "密码必须包含大小写字母和数字"
)
private String userPassword;
@NotBlank(message = "确认密码不能为空")
private String checkPassword;
@AssertTrue(message = "两次输入的密码不一致")
public boolean isPasswordMatch() {
if (userPassword == null || checkPassword == null) {
return true;
}
return userPassword.equals(checkPassword);
}
}
用户登录 DTO
package com.example.project.model.dto.user;
import lombok.Data;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Size;
import java.io.Serializable;
@Data
public class UserLoginDTO implements Serializable {
private static final long serialVersionUID = 1L;
@NotBlank(message = "账号不能为空")
@Size(max = 20, message = "账号长度不能超过20")
private String userAccount;
@NotBlank(message = "密码不能为空")
@Size(min = 8, max = 20, message = "密码长度必须在8-20之间")
private String userPassword;
}
用户更新 DTO(分组校验)
package com.example.project.model.dto.user;
import com.example.project.validation.groups.UpdateGroup;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Data;
import javax.validation.constraints.*;
import java.io.Serializable;
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
public class UserUpdateDTO implements Serializable {
private static final long serialVersionUID = 1L;
@NotNull(message = "用户ID不能为空", groups = UpdateGroup.class)
private Long id;
@Size(max = 50, message = "用户名长度不能超过50", groups = UpdateGroup.class)
private String userName;
@Size(max = 512, message = "头像URL长度不能超过512", groups = UpdateGroup.class)
private String avatarUrl;
private String gender;
@Pattern(
regexp = "^1[3-9]\\d{9}$",
message = "手机号格式不正确",
groups = UpdateGroup.class
)
private String phone;
@Email(message = "邮箱格式不正确", groups = UpdateGroup.class)
private String email;
@Size(max = 1024, message = "标签内容过长", groups = UpdateGroup.class)
private String tags;
}
分组校验接口
package com.example.project.validation.groups;
public interface UpdateGroup {
}
public interface CreateGroup {
}
4.2 VO(View Object)- 响应数据
package com.zwnsyw.zwwwspringbootbasetemplate.model.vo;
import com.zwnsyw.zwwwspringbootbasetemplate.model.entity.User;
import com.zwnsyw.zwwwspringbootbasetemplate.model.enums.GenderEnum;
import com.zwnsyw.zwwwspringbootbasetemplate.model.enums.UserRoleEnum;
import com.zwnsyw.zwwwspringbootbasetemplate.model.enums.UserStatusEnum;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serializable;
import java.time.LocalDateTime;
@Data
@Schema(description = "用户视图对象")
public class UserVO implements Serializable {
private static final long serialVersionUID = 1L;
@Schema(description = "用户ID")
private Long id;
@Schema(description = "用户昵称")
private String userName;
@Schema(description = "用户账号")
private String userAccount;
@Schema(description = "头像URL")
private String avatarUrl;
@Schema(description = "性别")
private GenderEnum gender;
@Schema(description = "手机号(脱敏)", example = "138****8888")
private String phone;
@Schema(description = "邮箱(脱敏)", example = "u***@example.com")
private String email;
@Schema(description = "用户状态")
private UserStatusEnum userStatus;
@Schema(description = "用户角色")
private UserRoleEnum userRole;
@Schema(description = "标签JSON")
private String tags;
@Schema(description = "创建时间")
private LocalDateTime createTime;
public static UserVO fromEntity(User user) {
if (user == null) {
return null;
}
UserVO vo = new UserVO();
vo.setId(user.getId());
vo.setUserName(user.getUserName());
vo.setUserAccount(user.getUserAccount());
vo.setAvatarUrl(user.getAvatarUrl());
vo.setGender(user.getGender());
vo.setPhone(maskPhone(user.getPhone()));
vo.setEmail(maskEmail(user.getEmail()));
vo.setUserStatus(user.getUserStatus());
vo.setUserRole(user.getUserRole());
vo.setTags(user.getTags());
vo.setCreateTime(user.getCreateTime());
return vo;
}
private static String maskPhone(String phone) {
if (phone == null || phone.length() < 11) {
return phone;
}
return phone.substring(0, 3) + "****" + phone.substring(7);
}
private static String maskEmail(String email) {
if (email == null || !email.contains("@")) {
return email;
}
int atIndex = email.indexOf("@");
if (atIndex <= 1) {
return email;
}
return email.charAt(0) + "***" + email.substring(atIndex);
}
}
4.3 DTO/VO 设计对比
| 特性 |
DTO |
VO |
| 用途 |
接收前端请求数据 |
返回给前端的响应数据 |
| 校验注解 |
✅ 必须有 |
❌ 不需要 |
| 脱敏处理 |
❌ 不需要 |
✅ 必须有 |
| 字段范围 |
仅包含请求需要的字段 |
仅包含需要展示的字段 |
| 敏感字段 |
可以包含(如密码) |
不能包含(如密码) |
5. 三层校验机制
5.1 校验分层架构图
┌─────────────────────────────────────────────────────────────┐
│ 前端校验 │
│ (表单验证、即时反馈) │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ Controller 层校验 │
│ ├── 请求参数非空判断 │
│ ├── 触发 DTO 校验 (@Valid) │
│ └── 简单的参数合法性检查 │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ DTO 层校验 │
│ ├── 字段非空校验 (@NotBlank, @NotNull) │
│ ├── 格式校验 (@Email, @Pattern) │
│ ├── 范围校验 (@Size, @Min, @Max) │
│ └── 复杂校验 (@AssertTrue 自定义方法) │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ Service 层校验 │
│ ├── 业务逻辑校验 │
│ ├── 数据库查询校验 (账号是否存在等) │
│ ├── 权限校验 │
│ └── 复杂业务规则校验 │
└─────────────────────────────────────────────────────────────┘
5.2 Controller 层
package com.zwnsyw.zwwwspringbootbasetemplate.controller;
import com.zwnsyw.zwwwspringbootbasetemplate.common.response.BaseResponse;
import com.zwnsyw.zwwwspringbootbasetemplate.common.response.ResultUtils;
import com.zwnsyw.zwwwspringbootbasetemplate.model.dto.UserLoginDTO;
import com.zwnsyw.zwwwspringbootbasetemplate.model.dto.UserRegisterDTO;
import com.zwnsyw.zwwwspringbootbasetemplate.model.dto.UserUpdateDTO;
import com.zwnsyw.zwwwspringbootbasetemplate.model.vo.LoginUserVO;
import com.zwnsyw.zwwwspringbootbasetemplate.model.vo.UserVO;
import com.zwnsyw.zwwwspringbootbasetemplate.service.UserService;
import com.zwnsyw.zwwwspringbootbasetemplate.validation.groups.UpdateGroup;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.HttpServletRequest;
import javax.validation.Valid;
@RestController
@RequestMapping("/user")
@RequiredArgsConstructor
@Tag(name = "用户模块", description = "用户注册、登录、信息管理等接口")
public class UserController {
private final UserService userService;
@PostMapping("/register")
@Operation(summary = "用户注册", description = "通过账号密码注册新用户")
public BaseResponse<Long> register(@RequestBody @Valid UserRegisterDTO dto) {
Long userId = userService.register(dto);
return ResultUtils.success(userId);
}
@PostMapping("/login")
@Operation(summary = "用户登录", description = "通过账号密码登录,成功后会创建 Session")
public BaseResponse<UserVO> login(
@RequestBody @Valid UserLoginDTO dto,
HttpServletRequest request) {
UserVO userVO = userService.login(dto, request);
return ResultUtils.success(userVO);
}
@PostMapping("/update")
@Operation(summary = "更新用户信息", description = "更新当前登录用户或指定用户的信息(管理员)")
public BaseResponse<Boolean> update(
@RequestBody @Validated(UpdateGroup.class) UserUpdateDTO dto,
HttpServletRequest request) {
LoginUserVO loginUser = userService.getLoginUser(request);
Boolean result = userService.updateUser(dto, loginUser);
return ResultUtils.success(result);
}
@GetMapping("/current")
@Operation(summary = "获取当前登录用户", description = "获取当前登录用户的详细信息(从数据库实时查询)")
public BaseResponse<UserVO> getCurrentUser(HttpServletRequest request) {
UserVO userVO = userService.getCurrentUserVO(request);
return ResultUtils.success(userVO);
}
@PostMapping("/logout")
@Operation(summary = "用户退出登录", description = "清除当前用户的登录状态")
public BaseResponse<Boolean> logout(HttpServletRequest request) {
Boolean result = userService.logout(request);
return ResultUtils.success(result);
}
@GetMapping("/{id}")
@Operation(summary = "根据ID获取用户", description = "获取指定用户的公开信息")
public BaseResponse<UserVO> getUserById(
@Parameter(description = "用户ID") @PathVariable Long id) {
UserVO userVO = userService.getUserVOById(id);
return ResultUtils.success(userVO);
}
}
5.3 Service 层
package com.zwnsyw.zwwwspringbootbasetemplate.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.zwnsyw.zwwwspringbootbasetemplate.model.dto.UserLoginDTO;
import com.zwnsyw.zwwwspringbootbasetemplate.model.dto.UserRegisterDTO;
import com.zwnsyw.zwwwspringbootbasetemplate.model.dto.UserUpdateDTO;
import com.zwnsyw.zwwwspringbootbasetemplate.model.entity.User;
import com.zwnsyw.zwwwspringbootbasetemplate.model.vo.LoginUserVO;
import com.zwnsyw.zwwwspringbootbasetemplate.model.vo.UserVO;
import javax.servlet.http.HttpServletRequest;
public interface UserService extends IService<User> {
Long register(UserRegisterDTO dto);
UserVO login(UserLoginDTO dto, HttpServletRequest request);
Boolean updateUser(UserUpdateDTO dto, LoginUserVO loginUser);
LoginUserVO getLoginUser(HttpServletRequest request);
UserVO getCurrentUserVO(HttpServletRequest request);
UserVO getUserVOById(Long id);
Boolean logout(HttpServletRequest request);
}
package com.zwnsyw.zwwwspringbootbasetemplate.service.serviceimpl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.zwnsyw.zwwwspringbootbasetemplate.exception.ErrorCode;
import com.zwnsyw.zwwwspringbootbasetemplate.exception.ThrowUtils;
import com.zwnsyw.zwwwspringbootbasetemplate.mapper.UserMapper;
import com.zwnsyw.zwwwspringbootbasetemplate.model.dto.UserLoginDTO;
import com.zwnsyw.zwwwspringbootbasetemplate.model.dto.UserRegisterDTO;
import com.zwnsyw.zwwwspringbootbasetemplate.model.dto.UserUpdateDTO;
import com.zwnsyw.zwwwspringbootbasetemplate.model.entity.User;
import com.zwnsyw.zwwwspringbootbasetemplate.model.enums.GenderEnum;
import com.zwnsyw.zwwwspringbootbasetemplate.model.enums.UserRoleEnum;
import com.zwnsyw.zwwwspringbootbasetemplate.model.enums.UserStatusEnum;
import com.zwnsyw.zwwwspringbootbasetemplate.model.vo.LoginUserVO;
import com.zwnsyw.zwwwspringbootbasetemplate.model.vo.UserVO;
import com.zwnsyw.zwwwspringbootbasetemplate.service.UserService;
import com.zwnsyw.zwwwspringbootbasetemplate.utils.PasswordUtils;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
@Slf4j
@Service
@RequiredArgsConstructor
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService {
private static final String USER_LOGIN_STATE = "USER_LOGIN_STATE";
private static final String ACCOUNT_PATTERN = "^[a-zA-Z0-9_]+$";
private final PasswordUtils passwordUtils;
@Override
public Long register(UserRegisterDTO dto) {
String userAccount = dto.getUserAccount();
String userPassword = dto.getUserPassword();
ThrowUtils.throwIf(
!userAccount.matches(ACCOUNT_PATTERN),
ErrorCode.USER_ACCOUNT_INVALID,
"账号只能包含字母、数字、下划线"
);
ThrowUtils.throwIf(
isAccountExists(userAccount),
ErrorCode.USER_ACCOUNT_ALREADY_EXISTS,
String.format("账号 '%s' 已被注册", userAccount)
);
String encryptedPassword = passwordUtils.encrypt(userPassword);
User user = new User();
user.setUserAccount(userAccount);
user.setUserPassword(encryptedPassword);
user.setUserName("用户" + System.currentTimeMillis());
user.setGender(GenderEnum.UNKNOWN);
user.setUserStatus(UserStatusEnum.ACTIVE);
user.setUserRole(UserRoleEnum.USER);
boolean saved = this.save(user);
ThrowUtils.throwIf(!saved, ErrorCode.USER_REGISTER_FAILED, "注册失败,请稍后重试");
log.info("用户注册成功,userId: {}, account: {}", user.getId(), userAccount);
return user.getId();
}
@Override
public UserVO login(UserLoginDTO dto, HttpServletRequest request) {
String userAccount = dto.getUserAccount();
String userPassword = dto.getUserPassword();
User user = getByAccount(userAccount);
if (user == null) {
log.info("用户登录失败,账号不存在: {}", userAccount);
throw new com.zwnsyw.zwwwspringbootbasetemplate.exception.BusinessException(
ErrorCode.USER_LOGIN_FAILED, "账号或密码错误"
);
}
if (!passwordUtils.verify(userPassword, user.getUserPassword())) {
log.info("用户登录失败,密码错误: {}", userAccount);
throw new com.zwnsyw.zwwwspringbootbasetemplate.exception.BusinessException(
ErrorCode.USER_LOGIN_FAILED, "账号或密码错误"
);
}
ThrowUtils.throwIf(
user.getUserStatus() == UserStatusEnum.DISABLED,
ErrorCode.USER_ACCOUNT_DISABLED,
String.format("账号 '%s' 已被禁用,如有疑问请联系管理员", userAccount)
);
ThrowUtils.throwIf(
user.getUserStatus() == UserStatusEnum.LOCKED,
ErrorCode.USER_ACCOUNT_LOCKED,
String.format("账号 '%s' 已被锁定,请稍后重试", userAccount)
);
HttpSession session = request.getSession();
LoginUserVO loginUserVO = LoginUserVO.fromEntity(user);
session.setAttribute(USER_LOGIN_STATE, loginUserVO);
log.info("用户登录成功,userId: {}, account: {}", user.getId(), userAccount);
return UserVO.fromEntity(user);
}
@Override
public Boolean updateUser(UserUpdateDTO dto, LoginUserVO loginUser) {
Long targetUserId = dto.getId();
boolean isSelf = targetUserId.equals(loginUser.getId());
boolean isAdmin = loginUser.getUserRole() != null && loginUser.getUserRole().isAdmin();
ThrowUtils.throwIf(
!isSelf && !isAdmin,
ErrorCode.USER_NO_PERMISSION,
"无权限修改其他用户信息"
);
User targetUser = ThrowUtils.throwIfNull(
this.getById(targetUserId),
ErrorCode.USER_NOT_FOUND,
String.format("用户 ID=%d 不存在", targetUserId)
);
updateUserFields(dto, targetUser);
boolean updated = this.updateById(targetUser);
ThrowUtils.throwIf(!updated, ErrorCode.USER_UPDATE_FAILED, "更新用户信息失败");
log.info("用户信息更新成功,targetUserId: {}, operatorId: {}", targetUserId, loginUser.getId());
return true;
}
@Override
public LoginUserVO getLoginUser(HttpServletRequest request) {
HttpSession session = request.getSession(false);
ThrowUtils.throwIf(session == null, ErrorCode.USER_NOT_LOGIN, "请先登录");
Object userObj = session.getAttribute(USER_LOGIN_STATE);
ThrowUtils.throwIfNull(userObj, ErrorCode.USER_NOT_LOGIN, "请先登录");
return (LoginUserVO) userObj;
}
@Override
public UserVO getCurrentUserVO(HttpServletRequest request) {
LoginUserVO loginUser = getLoginUser(request);
User user = ThrowUtils.throwIfNull(
this.getById(loginUser.getId()),
ErrorCode.USER_NOT_FOUND,
"用户不存在或已被删除"
);
return UserVO.fromEntity(user);
}
@Override
public UserVO getUserVOById(Long id) {
ThrowUtils.throwIfNull(id, ErrorCode.PARAMS_ERROR, "用户ID不能为空");
User user = ThrowUtils.throwIfNull(
this.getById(id),
ErrorCode.USER_NOT_FOUND,
String.format("用户 ID=%d 不存在", id)
);
return UserVO.fromEntity(user);
}
@Override
public Boolean logout(HttpServletRequest request) {
HttpSession session = request.getSession(false);
if (session != null) {
session.removeAttribute(USER_LOGIN_STATE);
log.info("用户退出登录成功");
}
return true;
}
private User getByAccount(String userAccount) {
LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(User::getUserAccount, userAccount);
return this.getOne(queryWrapper);
}
private boolean isAccountExists(String userAccount) {
LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(User::getUserAccount, userAccount);
return this.count(queryWrapper) > 0;
}
private boolean isPhoneUsedByOther(String phone, Long excludeUserId) {
LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(User::getPhone, phone)
.ne(User::getId, excludeUserId);
return this.count(queryWrapper) > 0;
}
private boolean isEmailUsedByOther(String email, Long excludeUserId) {
LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(User::getEmail, email)
.ne(User::getId, excludeUserId);
return this.count(queryWrapper) > 0;
}
private void updateUserFields(UserUpdateDTO dto, User targetUser) {
Long userId = targetUser.getId();
if (StringUtils.isNotBlank(dto.getUserName())) {
targetUser.setUserName(dto.getUserName());
}
if (StringUtils.isNotBlank(dto.getAvatarUrl())) {
targetUser.setAvatarUrl(dto.getAvatarUrl());
}
if (StringUtils.isNotBlank(dto.getGender())) {
targetUser.setGender(GenderEnum.fromCode(dto.getGender()));
}
if (StringUtils.isNotBlank(dto.getPhone())) {
ThrowUtils.throwIf(
isPhoneUsedByOther(dto.getPhone(), userId),
ErrorCode.USER_PHONE_ALREADY_EXISTS,
String.format("手机号 '%s' 已被其他用户使用", dto.getPhone())
);
targetUser.setPhone(dto.getPhone());
}
if (StringUtils.isNotBlank(dto.getEmail())) {
ThrowUtils.throwIf(
isEmailUsedByOther(dto.getEmail(), userId),
ErrorCode.USER_EMAIL_ALREADY_EXISTS,
String.format("邮箱 '%s' 已被其他用户使用", dto.getEmail())
);
targetUser.setEmail(dto.getEmail());
}
if (StringUtils.isNotBlank(dto.getTags())) {
targetUser.setTags(dto.getTags());
}
}
}
PasswordUtils
package com.zwnsyw.zwwwspringbootbasetemplate.utils;
import com.zwnsyw.zwwwspringbootbasetemplate.config.AppProperties;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.mindrot.jbcrypt.BCrypt;
import org.springframework.stereotype.Component;
@Slf4j
@Component
@RequiredArgsConstructor
public class PasswordUtils {
private final AppProperties appProperties;
public String encrypt(String rawPassword) {
if (rawPassword == null || rawPassword.isEmpty()) {
throw new IllegalArgumentException("密码不能为空");
}
String enhancedPassword = enhance(rawPassword);
String salt = BCrypt.gensalt(appProperties.getSecurity().getBcryptStrength());
return BCrypt.hashpw(enhancedPassword, salt);
}
public boolean verify(String rawPassword, String encryptedPassword) {
if (rawPassword == null || encryptedPassword == null) {
return false;
}
try {
String enhancedPassword = enhance(rawPassword);
return BCrypt.checkpw(enhancedPassword, encryptedPassword);
} catch (Exception e) {
log.warn("密码验证异常: {}", e.getMessage());
return false;
}
}
private String enhance(String rawPassword) {
return appProperties.getSecurity().getPasswordSalt() + rawPassword;
}
public boolean needsRehash(String encryptedPassword) {
if (encryptedPassword == null || !encryptedPassword.startsWith("$2")) {
return true;
}
try {
String[] parts = encryptedPassword.split("\\$");
if (parts.length >= 3) {
int currentStrength = Integer.parseInt(parts[2]);
return currentStrength < appProperties.getSecurity().getBcryptStrength();
}
} catch (Exception e) {
log.warn("解析密码哈希强度失败: {}", e.getMessage());
}
return true;
}
}
5.4 三层校验职责总结
| 层级 |
校验类型 |
示例 |
| Controller |
参数非空、触发DTO校验 |
dto == null、@Valid |
| DTO |
字段格式、长度、范围 |
@NotBlank、@Size、@Pattern、@AssertTrue |
| Service |
业务逻辑、数据库校验 |
账号是否存在、密码是否正确、权限校验 |
6. 代码生成与配置
6.1 Maven 依赖
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.zwnsyw</groupId>
<artifactId>ZwwwSpringBootBaseTemplate</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>ZwwwSpringBootBaseTemplate</name>
<description>ZwwwSpringBootBaseTemplate</description>
<properties>
<java.version>1.8</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<spring-boot.version>2.7.6</spring-boot.version>
<mybatis-plus.version>3.5.3.1</mybatis-plus.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.mindrot</groupId>
<artifactId>jbcrypt</artifactId>
<version>0.4</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.26</version>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.12.0</version>
</dependency>
<dependency>
<groupId>com.github.xiaoymin</groupId>
<artifactId>knife4j-openapi3-spring-boot-starter</artifactId>
<version>4.4.0</version>
</dependency>
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp</artifactId>
<version>4.10.0</version>
</dependency>
</dependencies>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>${spring-boot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.8.1</version>
<configuration>
<source>1.8</source>
<target>1.8</target>
<encoding>UTF-8</encoding>
</configuration>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<version>${spring-boot.version}</version>
<configuration>
<mainClass>com.zwnsyw.zwwwspringbootbasetemplate.ZwwwSpringBootBaseTemplateApplication</mainClass>
<skip>true</skip>
</configuration>
<executions>
<execution>
<id>repackage</id>
<goals>
<goal>repackage</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
6.2 Spring Boot YAML 配置详解与多环境管理
application.yml(主配置文件)
server:
port: 8080
servlet:
context-path: /api
tomcat:
max-http-form-post-size: 100MB
max-swallow-size: -1
spring:
application:
name: project-name
profiles:
active: ${SPRING_PROFILES_ACTIVE:dev}
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/database_name?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
username: root
password: your_password
servlet:
multipart:
enabled: true
max-file-size: 10MB
max-request-size: 50MB
redis:
host: localhost
port: 6379
database: 0
timeout: 3000ms
lettuce:
pool:
max-active: 8
max-idle: 8
min-idle: 0
max-wait: -1ms
cache:
type: redis
redis:
time-to-live: 300s
cache-null-values: true
session:
store-type: redis
redis:
namespace: ${spring.application.name}:session
flush-mode: on_save
timeout: 86400s
mybatis-plus:
configuration:
map-underscore-to-camel-case: true
default-enum-type-handler: org.apache.ibatis.type.EnumOrdinalTypeHandler
global-config:
db-config:
logic-delete-field: isDeleted
logic-delete-value: 1
logic-not-delete-value: 0
knife4j:
enable: true
openapi:
title: "${app.name} 接口文档"
description: "API 接口说明文档"
version: ${app.version}
group:
default:
api-rule: package
api-rule-resources:
- com.example.projectname.controller
app:
name: project-name
version: 1.0.0
debug: false
file:
max-size: 10485760
allowed-formats: jpg,jpeg,png,gif,webp,pdf
upload-path: /uploads
jwt:
secret: ${JWT_SECRET:your-default-secret-key-please-change-in-production}
expiration: 604800
token-prefix: "Bearer "
header-name: Authorization
user:
max-password-retry: 5
max-login-device: 3
lock-minutes: 30
cors:
allowed-origins: ${CORS_ORIGINS:http://localhost:5173,http://localhost:3000}
logging:
level:
root: INFO
com.example.projectname: DEBUG
pattern:
console: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n"
application-dev.yml(开发环境)
spring:
datasource:
url: jdbc:mysql://localhost:3306/dev_database?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
username: root
password: dev_password
redis:
host: localhost
port: 6379
database: 0
mybatis-plus:
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
knife4j:
enable: true
app:
debug: true
jwt:
expiration: 86400
cors:
allowed-origins: "*"
logging:
level:
com.example.projectname: DEBUG
com.example.projectname.mapper: DEBUG
application-test.yml(测试环境)
spring:
datasource:
url: jdbc:mysql://${DB_HOST:test-db.example.com}:3306/test_database?useSSL=true&serverTimezone=Asia/Shanghai
username: ${DB_USERNAME:test_user}
password: ${DB_PASSWORD:test_password}
redis:
host: ${REDIS_HOST:test-redis.example.com}
port: 6379
password: ${REDIS_PASSWORD:}
database: 1
mybatis-plus:
configuration:
log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl
knife4j:
enable: true
app:
debug: false
jwt:
secret: ${JWT_SECRET:test-environment-jwt-secret-key}
cors:
allowed-origins: https://test.example.com,https://test-admin.example.com
logging:
level:
root: INFO
com.example.projectname: DEBUG
file:
name: logs/application-test.log
max-size: 50MB
max-history: 15
application-prod.yml(生产环境)
server:
tomcat:
threads:
max: 200
min-spare: 20
spring:
datasource:
url: jdbc:mysql://${DB_HOST}:${DB_PORT:3306}/${DB_NAME}?useSSL=true&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true
username: ${DB_USERNAME}
password: ${DB_PASSWORD}
hikari:
maximum-pool-size: 20
minimum-idle: 5
idle-timeout: 300000
connection-timeout: 20000
max-lifetime: 1200000
redis:
host: ${REDIS_HOST}
port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD}
database: 0
lettuce:
pool:
max-active: 16
max-idle: 8
min-idle: 4
mybatis-plus:
configuration:
log-impl: org.apache.ibatis.logging.nologging.NoLoggingImpl
knife4j:
enable: false
app:
debug: false
jwt:
secret: ${JWT_SECRET}
expiration: 604800
cors:
allowed-origins: ${CORS_ORIGINS:https://www.example.com,https://admin.example.com}
logging:
level:
root: WARN
com.example.projectname: INFO
file:
name: /var/log/application/app.log
max-size: 100MB
max-history: 30
多环境配置详解
6.3 配置类
CacheConfig.java
package com.zwnsyw.zwwwspringbootbasetemplate.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.RedisSerializer;
import org.springframework.cache.CacheManager;
import java.time.Duration;
import java.util.HashMap;
import java.util.Map;
@Configuration
public class CacheConfig {
private static final long DEFAULT_TTL_HOURS = 2;
@Bean
public CacheManager cacheManager(RedisConnectionFactory connectionFactory) {
RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofHours(DEFAULT_TTL_HOURS))
.disableCachingNullValues()
.serializeKeysWith(RedisSerializationContext.SerializationPair
.fromSerializer(RedisSerializer.string()))
.serializeValuesWith(RedisSerializationContext.SerializationPair
.fromSerializer(new GenericJackson2JsonRedisSerializer()));
Map<String, RedisCacheConfiguration> cacheConfigurations = new HashMap<>();
cacheConfigurations.put("user", defaultConfig.entryTtl(Duration.ofMinutes(30)));
cacheConfigurations.put("hot", defaultConfig.entryTtl(Duration.ofMinutes(10)));
cacheConfigurations.put("dict", defaultConfig.entryTtl(Duration.ofHours(24)));
return RedisCacheManager.builder(connectionFactory)
.cacheDefaults(defaultConfig)
.withInitialCacheConfigurations(cacheConfigurations)
.build();
}
}
CorsConfig.java
package com.example.projectname.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Value("${cors.allowed-origins:*}")
private String[] allowedOrigins;
private static final long MAX_AGE_SECONDS = 3600;
@Override
public void addCorsMappings(CorsRegistry registry) {
registry
.addMapping("/**")
.allowCredentials(true)
.allowedOriginPatterns(allowedOrigins)
.allowedMethods("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")
.allowedHeaders("*")
.exposedHeaders("X-Total-Count", "Content-Disposition")
.maxAge(MAX_AGE_SECONDS);
}
}
JsonConfig.java
package com.example.projectname.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.databind.module.SimpleModule;
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateDeserializer;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateSerializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer;
import org.springframework.boot.jackson.JsonComponent;
import org.springframework.context.annotation.Bean;
import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
@JsonComponent
public class JsonConfig {
private static final String DATE_FORMAT = "yyyy-MM-dd";
private static final String DATE_TIME_FORMAT = "yyyy-MM-dd HH:mm:ss";
@Bean
public ObjectMapper jacksonObjectMapper(Jackson2ObjectMapperBuilder builder) {
ObjectMapper objectMapper = builder.createXmlMapper(false).build();
SimpleModule longModule = new SimpleModule();
longModule.addSerializer(Long.class, ToStringSerializer.instance);
longModule.addSerializer(Long.TYPE, ToStringSerializer.instance);
objectMapper.registerModule(longModule);
JavaTimeModule javaTimeModule = new JavaTimeModule();
javaTimeModule.addSerializer(LocalDate.class,
new LocalDateSerializer(DateTimeFormatter.ofPattern(DATE_FORMAT)));
javaTimeModule.addDeserializer(LocalDate.class,
new LocalDateDeserializer(DateTimeFormatter.ofPattern(DATE_FORMAT)));
javaTimeModule.addSerializer(LocalDateTime.class,
new LocalDateTimeSerializer(DateTimeFormatter.ofPattern(DATE_TIME_FORMAT)));
javaTimeModule.addDeserializer(LocalDateTime.class,
new LocalDateTimeDeserializer(DateTimeFormatter.ofPattern(DATE_TIME_FORMAT)));
objectMapper.registerModule(javaTimeModule);
objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
return objectMapper;
}
}
MyBatisPlusConfig.java
package com.zwnsyw.zwwwspringbootbasetemplate.config;
import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.OptimisticLockerInnerInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.reflection.MetaObject;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.time.LocalDateTime;
@Configuration
@MapperScan("com.zwnsyw.zwwwspringbootbasetemplate.mapper")
@Slf4j
public class MyBatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
PaginationInnerInterceptor paginationInterceptor = new PaginationInnerInterceptor(DbType.MYSQL);
paginationInterceptor.setMaxLimit(500L);
paginationInterceptor.setOverflow(false);
interceptor.addInnerInterceptor(paginationInterceptor);
interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());
return interceptor;
}
@Bean
public MetaObjectHandler metaObjectHandler() {
return new MetaObjectHandler() {
@Override
public void insertFill(MetaObject metaObject) {
log.debug("开始插入填充...");
this.strictInsertFill(metaObject, "createTime", LocalDateTime.class, LocalDateTime.now());
this.strictInsertFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now());
this.strictInsertFill(metaObject, "isDeleted", Integer.class, 0);
}
@Override
public void updateFill(MetaObject metaObject) {
log.debug("开始更新填充...");
this.strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now());
}
};
}
}
RedisConfig.java
package com.example.projectname.config;
import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.jsontype.BasicPolymorphicTypeValidator;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializer;
@Configuration
@Slf4j
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
template.setKeySerializer(RedisSerializer.string());
template.setHashKeySerializer(RedisSerializer.string());
GenericJackson2JsonRedisSerializer valueSerializer = createJsonSerializer();
template.setValueSerializer(valueSerializer);
template.setHashValueSerializer(valueSerializer);
template.setDefaultSerializer(valueSerializer);
template.afterPropertiesSet();
log.info("RedisTemplate 配置完成,使用 JSON 序列化");
return template;
}
@Bean
public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory connectionFactory) {
StringRedisTemplate template = new StringRedisTemplate();
template.setConnectionFactory(connectionFactory);
return template;
}
private GenericJackson2JsonRedisSerializer createJsonSerializer() {
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
BasicPolymorphicTypeValidator typeValidator = BasicPolymorphicTypeValidator.builder()
.allowIfSubType("com.example.projectname")
.allowIfSubType("java.util.ArrayList")
.allowIfSubType("java.util.LinkedList")
.allowIfSubType("java.util.HashMap")
.allowIfSubType("java.util.LinkedHashMap")
.allowIfSubType("java.util.TreeMap")
.allowIfSubType("java.util.HashSet")
.allowIfSubType("java.util.LinkedHashSet")
.allowIfSubType("java.util.TreeSet")
.allowIfSubType("java.lang.String")
.allowIfSubType("java.lang.Long")
.allowIfSubType("java.lang.Integer")
.allowIfSubType("java.lang.Double")
.allowIfSubType("java.lang.Boolean")
.allowIfSubType("java.math.BigDecimal")
.allowIfSubType("java.math.BigInteger")
.allowIfSubType("java.util.Date")
.allowIfSubType("java.sql.Timestamp")
.allowIfSubType("java.time.LocalDate")
.allowIfSubType("java.time.LocalDateTime")
.allowIfSubType("java.time.LocalTime")
.allowIfSubType("com.baomidou.mybatisplus.extension.plugins.pagination.Page")
.build();
objectMapper.activateDefaultTyping(typeValidator, ObjectMapper.DefaultTyping.NON_FINAL);
return new GenericJackson2JsonRedisSerializer(objectMapper);
}
}
SessionConfig.java
package com.example.projectname.config;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializer;
import org.springframework.session.data.redis.config.annotation.web.http.EnableRedisHttpSession;
@Configuration
@EnableRedisHttpSession(
// Session 过期时间:30天
maxInactiveIntervalInSeconds = 86400 * 30,
// Redis 中 Session 的命名空间前缀
redisNamespace = "session"
)
public class SessionConfig {
@Bean
@Qualifier("springSessionDefaultRedisSerializer")
public RedisSerializer springSessionDefaultRedisSerializer() {
return new GenericJackson2JsonRedisSerializer();
}
}
WebMvcConfig.java
package com.example.projectname.config;
import com.example.projectname.interceptor.AuthInterceptor;
import com.example.projectname.interceptor.LogInterceptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import java.util.Arrays;
import java.util.List;
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Autowired(required = false)
private AuthInterceptor authInterceptor;
@Autowired(required = false)
private LogInterceptor logInterceptor;
private static final List<String> EXCLUDE_PATHS = Arrays.asList(
"/user/login",
"/user/register",
"/user/captcha",
"/static/**",
"/favicon.ico",
"/doc.html",
"/swagger-resources/**",
"/webjars/**",
"/v2/api-docs",
"/v3/api-docs/**",
"/actuator/**",
"/error"
);
@Override
public void addInterceptors(InterceptorRegistry registry) {
if (logInterceptor != null) {
registry.addInterceptor(logInterceptor)
.addPathPatterns("/**")
.order(0);
}
if (authInterceptor != null) {
registry.addInterceptor(authInterceptor)
.addPathPatterns("/**")
.excludePathPatterns(EXCLUDE_PATHS)
.order(1);
}
}
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("doc.html")
.addResourceLocations("classpath:/META-INF/resources/");
registry.addResourceHandler("/webjars/**")
.addResourceLocations("classpath:/META-INF/resources/webjars/");
}
}
Knife4jConfig.java
package com.zwnsyw.zwwwspringbootbasetemplate.config;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Contact;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.info.License;
import io.swagger.v3.oas.models.security.SecurityRequirement;
import io.swagger.v3.oas.models.security.SecurityScheme;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
@Configuration
@Profile({"dev", "test"})
public class Knife4jConfig {
@Bean
public OpenAPI customOpenAPI() {
return new OpenAPI()
.info(new Info()
.title("项目接口文档")
.version("1.0.0")
.description("RESTful API 接口说明文档")
.contact(new Contact()
.name("zwww")
.url("https://zwnsyw.top")
.email("2446796988@qq.com"))
.license(new License()
.name("Apache 2.0")
.url("http://www.apache.org/licenses/LICENSE-2.0")))
.addSecurityItem(new SecurityRequirement().addList("Bearer Token"))
.components(new io.swagger.v3.oas.models.Components()
.addSecuritySchemes("Bearer Token",
new SecurityScheme()
.type(SecurityScheme.Type.HTTP)
.scheme("bearer")
.bearerFormat("JWT")
.description("JWT Token")));
}
}
ThreadPoolConfig.java
package com.example.projectname.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.Executor;
import java.util.concurrent.ThreadPoolExecutor;
@Configuration
@EnableAsync
public class ThreadPoolConfig {
private static final int CORE_POOL_SIZE = 4;
private static final int MAX_POOL_SIZE = 10;
private static final int QUEUE_CAPACITY = 100;
private static final int KEEP_ALIVE_SECONDS = 60;
private static final String THREAD_NAME_PREFIX = "async-task-";
@Bean("asyncExecutor")
public Executor asyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(CORE_POOL_SIZE);
executor.setMaxPoolSize(MAX_POOL_SIZE);
executor.setQueueCapacity(QUEUE_CAPACITY);
executor.setKeepAliveSeconds(KEEP_ALIVE_SECONDS);
executor.setThreadNamePrefix(THREAD_NAME_PREFIX);
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(60);
executor.initialize();
return executor;
}
@Bean("scheduledExecutor")
public Executor scheduledExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(2);
executor.setMaxPoolSize(4);
executor.setQueueCapacity(50);
executor.setThreadNamePrefix("scheduled-task-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
return executor;
}
}
AppProperties.java
package com.example.projectname.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
import org.springframework.validation.annotation.Validated;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Positive;
@Data
@Configuration
@ConfigurationProperties(prefix = "app")
@Validated
public class AppProperties {
private String name = "project-name";
private String version = "1.0.0";
private boolean debug = false;
private FileConfig file = new FileConfig();
private JwtConfig jwt = new JwtConfig();
private UserConfig user = new UserConfig();
@Data
public static class FileConfig {
@Positive(message = "文件大小必须为正数")
private long maxSize = 10 * 1024 * 1024;
private String allowedFormats = "jpg,jpeg,png,gif,webp,pdf";
private String uploadPath = "/uploads";
}
@Data
public static class JwtConfig {
@NotBlank(message = "JWT 密钥不能为空")
private String secret = "your-default-secret-key-please-change-in-production";
@Positive(message = "过期时间必须为正数")
private long expiration = 7 * 24 * 60 * 60;
private String tokenPrefix = "Bearer ";
private String headerName = "Authorization";
}
@Data
public static class UserConfig {
private int maxPasswordRetry = 5;
private int maxLoginDevice = 3;
private int lockMinutes = 30;
}
}
配置类总览
| 配置类 |
作用 |
关键配置项 |
| CacheConfig |
Spring Cache 缓存配置 |
缓存过期时间、序列化方式 |
| CorsConfig |
跨域配置 |
允许的域名、方法、请求头 |
| JsonConfig |
JSON 序列化配置 |
Long 转 String、日期格式化 |
| MyBatisPlusConfig |
ORM 配置 |
分页、乐观锁、自动填充 |
| RedisConfig |
Redis 配置 |
序列化方式、类型白名单 |
| SessionConfig |
分布式会话配置 |
过期时间、序列化方式 |
| WebMvcConfig |
MVC 配置 |
拦截器、静态资源映射 |
| Knife4jConfig |
接口文档配置 |
文档信息、扫描范围 |
| ThreadPoolConfig |
线程池配置 |
核心线程数、队列容量 |
| AppProperties |
业务配置 |
文件上传、JWT、用户配置 |
使用建议
- 按需引入:根据项目需要选择配置类
- 修改包路径:将
com.example.projectname 替换为你的实际包路径
- 敏感信息外置:密钥等敏感信息通过环境变量配置
- 生产环境:关闭接口文档、调整日志级别
7. 最佳实践总结
7.1 命名规范
| 类型 |
命名格式 |
示例 |
| Entity |
XxxEntity 或 Xxx |
User、Team |
| DTO |
XxxDTO 或 XxxRequest |
UserRegisterDTO |
| VO |
XxxVO |
UserVO |
| Enum |
XxxEnum |
GenderEnum |
| Service |
XxxService |
UserService |
| ServiceImpl |
XxxServiceImpl |
UserServiceImpl |
| Controller |
XxxController |
UserController |
| Mapper |
XxxMapper |
UserMapper |
8.2 分层职责
┌─────────────┐
│ Controller │ → 参数接收、非空校验、触发DTO校验、调用Service
├─────────────┤
│ Service │ → 业务逻辑、业务校验、事务管理、调用Mapper
├─────────────┤
│ Mapper │ → 数据库操作(CRUD)
├─────────────┤
│ Entity │ → 数据库表映射
├─────────────┤
│ DTO │ → 请求数据封装、字段校验
├─────────────┤
│ VO │ → 响应数据封装、脱敏处理
└─────────────┘
8.3 校验注解速查
| 注解 |
作用 |
示例 |
@NotNull |
不能为 null |
@NotNull Long id |
@NotBlank |
字符串非空且非空白 |
@NotBlank String name |
@NotEmpty |
集合/数组非空 |
@NotEmpty List<String> tags |
@Size |
长度/大小范围 |
@Size(min=1, max=10) |
@Min / @Max |
数值范围 |
@Min(0) @Max(100) |
@Email |
邮箱格式 |
@Email String email |
@Pattern |
正则匹配 |
@Pattern(regexp="...") |
@AssertTrue |
自定义校验方法 |
@AssertTrue isValid() |
@Valid |
触发嵌套校验 |
@Valid AddressDTO address |
@Validated |
分组校验 |
@Validated(UpdateGroup.class) |
8.4 枚举设计要点
- @EnumValue:标记存储到数据库的字段
- @JsonValue:标记序列化到 JSON 的字段
- @JsonCreator:标记反序列化的工厂方法
- fromCode():提供根据 code 获取枚举的静态方法
- isValid():提供验证 code 是否有效的方法
8.5 MybatisX 代码生成使用建议
- 生成到临时目录,手动迁移到项目中
- Entity 生成后需添加枚举字段映射
- Mapper 和 Service 可直接使用生成结果
- 按需修改生成的代码,添加业务逻辑
💬 评论